diff options
Diffstat (limited to 'app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx')
| -rw-r--r-- | app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx | 353 |
1 files changed, 353 insertions, 0 deletions
diff --git a/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx b/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx new file mode 100644 index 00000000..00c375a9 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx @@ -0,0 +1,353 @@ +"use client"; + +import * as React from "react"; +import { ChevronDown, ChevronRight, Minus, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { DepartmentNode } from "@/lib/users/knox-service"; +import { getDomainLabel, getDomainColor } from "./domain-constants"; + +interface DepartmentAssignment { + id: number; + departmentCode: string; + assignedDomain: string; + description?: string | null; +} + +interface DepartmentTreeViewProps { + departments: DepartmentNode[]; + selectedDepartments: string[]; + onSelectionChange: (selected: string[]) => void; + assignments: DepartmentAssignment[]; + className?: string; +} + +interface TreeNodeProps { + node: DepartmentNode; + selectedDepartments: string[]; + onToggle: (departmentCode: string) => void; + expandedNodes: Set<string>; + onExpandToggle: (departmentCode: string) => void; + assignments: DepartmentAssignment[]; + level: number; +} + +function TreeNode({ + node, + selectedDepartments, + onToggle, + expandedNodes, + onExpandToggle, + assignments, + level +}: TreeNodeProps) { + const isExpanded = expandedNodes.has(node.departmentCode); + const hasChildren = node.children.length > 0; + + // 현재 부서에 할당된 도메인 찾기 + const assignment = assignments.find(a => a.departmentCode === node.departmentCode); + + // 현재 노드의 선택 상태 확인 + const isSelected = selectedDepartments.includes(node.departmentCode); + + // 하위 노드들 중 선택된 것이 있는지 확인 (부분 선택 상태 표시용) + const hasSelectedChildren = React.useMemo(() => { + if (!hasChildren) return false; + + const getAllChildCodes = (dept: DepartmentNode): string[] => { + const codes: string[] = []; + dept.children.forEach(child => { + codes.push(child.departmentCode); + codes.push(...getAllChildCodes(child)); + }); + return codes; + }; + + const childCodes = getAllChildCodes(node); + return childCodes.some(code => selectedDepartments.includes(code)); + }, [node, selectedDepartments, hasChildren]); + + const handleToggle = () => { + onToggle(node.departmentCode); + }; + + const handleExpandToggle = () => { + if (hasChildren) { + onExpandToggle(node.departmentCode); + } + }; + + return ( + <div className="select-none"> + <div + className={cn( + "flex items-center gap-2 py-2 px-3 hover:bg-accent/50 rounded-md transition-colors", + (isSelected || (!isSelected && hasSelectedChildren)) && "bg-accent/20" + )} + style={{ marginLeft: `${level * 16}px` }} + > + {/* 확장/축소 버튼 */} + <div className="flex items-center justify-center w-5 h-5"> + {hasChildren ? ( + <Button + variant="ghost" + size="sm" + className="h-5 w-5 p-0 hover:bg-transparent" + onClick={handleExpandToggle} + > + {isExpanded ? ( + <ChevronDown className="h-3 w-3" /> + ) : ( + <ChevronRight className="h-3 w-3" /> + )} + </Button> + ) : null} + </div> + + {/* 체크박스 */} + <div className="flex items-center"> + <Checkbox + checked={isSelected} + onCheckedChange={handleToggle} + className={cn( + "h-4 w-4", + !isSelected && hasSelectedChildren && "[&>*:first-child]:opacity-50" + )} + /> + </div> + + {/* 부서 정보 */} + <div className="flex-1 min-w-0 cursor-pointer" onClick={handleToggle}> + <div className="flex items-center gap-2"> + <span className={cn( + "font-medium truncate", + (isSelected || (!isSelected && hasSelectedChildren)) && "text-primary" + )}> + {node.departmentName || node.departmentCode} + </span> + + {/* 할당된 도메인 표시 */} + {assignment && ( + <Badge + className={cn( + "text-xs shrink-0", + getDomainColor(assignment.assignedDomain) + )} + variant="outline" + > + {getDomainLabel(assignment.assignedDomain)} + </Badge> + )} + + {/* lowDepartmentYn 표시 */} + {node.lowDepartmentYn === 'T' && ( + <Badge + variant="secondary" + className="text-xs shrink-0" + > + 하위 + </Badge> + )} + </div> + + {/* 부서 코드 */} + <div className="text-xs text-muted-foreground truncate"> + {node.departmentCode} + {assignment?.description && ( + <span className="ml-1">• {assignment.description}</span> + )} + </div> + </div> + </div> + + {/* 하위 노드들 */} + {hasChildren && isExpanded && ( + <div className="mt-1"> + {node.children.map((child) => ( + <TreeNode + key={child.departmentCode} + node={child} + selectedDepartments={selectedDepartments} + onToggle={onToggle} + expandedNodes={expandedNodes} + onExpandToggle={onExpandToggle} + assignments={assignments} + level={level + 1} + /> + ))} + </div> + )} + </div> + ); +} + +export function DepartmentTreeView({ + departments, + selectedDepartments, + onSelectionChange, + assignments, + className, +}: DepartmentTreeViewProps) { + const [expandedNodes, setExpandedNodes] = React.useState<Set<string>>(new Set()); + + // 부서 토글 핸들러 + const handleToggle = (departmentCode: string) => { + const findNode = (nodes: DepartmentNode[], code: string): DepartmentNode | null => { + for (const node of nodes) { + if (node.departmentCode === code) return node; + const found = findNode(node.children, code); + if (found) return found; + } + return null; + }; + + const getAllChildCodes = (node: DepartmentNode): string[] => { + const codes: string[] = []; + node.children.forEach(child => { + codes.push(child.departmentCode); + codes.push(...getAllChildCodes(child)); + }); + return codes; + }; + + const targetNode = findNode(departments, departmentCode); + if (!targetNode) return; + + const isCurrentlySelected = selectedDepartments.includes(departmentCode); + + let newSelected: string[]; + if (isCurrentlySelected) { + // 선택 해제: 해당 부서만 제거 (하위 부서는 유지, 상위 부서에도 영향 없음) + newSelected = selectedDepartments.filter(code => code !== departmentCode); + } else { + // 선택: 해당 부서 + 모든 하위 부서 추가 + const childCodes = getAllChildCodes(targetNode); + const codesToAdd = [departmentCode, ...childCodes].filter(code => !selectedDepartments.includes(code)); + newSelected = [...selectedDepartments, ...codesToAdd]; + } + + onSelectionChange(newSelected); + }; + + // 노드 확장/축소 핸들러 + const handleExpandToggle = (departmentCode: string) => { + const newExpanded = new Set(expandedNodes); + if (newExpanded.has(departmentCode)) { + newExpanded.delete(departmentCode); + } else { + newExpanded.add(departmentCode); + } + setExpandedNodes(newExpanded); + }; + + // 전체 확장/축소 + const handleExpandAll = () => { + if (expandedNodes.size === 0) { + const getAllCodes = (nodes: DepartmentNode[]): string[] => { + const codes: string[] = []; + nodes.forEach(node => { + if (node.children.length > 0) { + codes.push(node.departmentCode); + codes.push(...getAllCodes(node.children)); + } + }); + return codes; + }; + setExpandedNodes(new Set(getAllCodes(departments))); + } else { + setExpandedNodes(new Set()); + } + }; + + // 전체 선택/해제 + const handleSelectAll = () => { + if (selectedDepartments.length === 0) { + // 전체 선택 + const allCodes: string[] = []; + const collectCodes = (nodes: DepartmentNode[]) => { + nodes.forEach(node => { + allCodes.push(node.departmentCode); + collectCodes(node.children); + }); + }; + collectCodes(departments); + onSelectionChange(allCodes); + } else { + // 전체 해제 + onSelectionChange([]); + } + }; + + return ( + <div className={cn("border rounded-lg", className)}> + {/* 헤더 */} + <div className="flex items-center justify-between p-3 border-b bg-muted/30"> + <h3 className="font-medium">조직도</h3> + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleExpandAll} + className="text-xs" + > + {expandedNodes.size === 0 ? ( + <> + <Plus className="mr-1 h-3 w-3" /> + 전체 펼치기 + </> + ) : ( + <> + <Minus className="mr-1 h-3 w-3" /> + 전체 접기 + </> + )} + </Button> + <Button + variant="outline" + size="sm" + onClick={handleSelectAll} + className="text-xs" + > + {selectedDepartments.length === 0 ? "전체 선택" : "선택 해제"} + </Button> + </div> + </div> + + {/* 트리 본문 */} + <ScrollArea className="h-[80vh] p-2"> + {departments.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + 부서 정보가 없습니다 + </div> + ) : ( + <div className="space-y-1"> + {departments.map((dept) => ( + <TreeNode + key={dept.departmentCode} + node={dept} + selectedDepartments={selectedDepartments} + onToggle={handleToggle} + expandedNodes={expandedNodes} + onExpandToggle={handleExpandToggle} + assignments={assignments} + level={0} + /> + ))} + </div> + )} + </ScrollArea> + + {/* 푸터 */} + {selectedDepartments.length > 0 && ( + <div className="border-t p-3 bg-muted/30"> + <div className="text-sm text-muted-foreground"> + 선택된 부서: <span className="font-medium text-foreground">{selectedDepartments.length}개</span> + </div> + </div> + )} + </div> + ); +}
\ No newline at end of file |
